Skip to content

fix: make agent-device replays fail safely - #97555

Merged
rlinoz merged 23 commits into
Expensify:mainfrom
callstack-internal:fix/agent-device-headless-replay-trustworthy
Sep 7, 2026
Merged

rlinoz merged 23 commits into
Expensify:mainfrom
callstack-internal:fix/agent-device-headless-replay-trustworthy

Conversation

@BartekObudzinski

@BartekObudzinski BartekObudzinski commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

Problem: Headless agent-device replays could silently report success from the wrong screen, spend all measured runs, and then report "No captured runs" with no indication of what went wrong. The replay CLI timeout on 0.20.x left the daemon request running, blocking the next run. Measurement flows were mixed with interactive macros inside the agent-device skill, with no single place to look up which flow owns a given Sentry span.

Solution: Moved all Sentry span flows into measure-telemetry-span, collocated with the runner and deadline wrapper. Each flow declares # @span with exactly one owner per span; the runner refuses to pick between two candidates. Preconditions and postconditions from flow headers are now enforced independently of the flow body. The runner fails on the first replay that emits no span and prints the current screen instead of exhausting all runs first.

Fixed Issues

$ #100391
PROPOSAL:

Tests

  1. Run node --test .claude/skills/measure-telemetry-span/scripts/replay-with-deadline.test.mjs and verify all 3 tests pass.
  2. Run bash -n .claude/skills/measure-telemetry-span/measure.sh and npx shellcheck -e SC1091 .claude/skills/measure-telemetry-span/measure.sh.
  3. On Android, run .claude/skills/measure-telemetry-span/measure.sh ManualOpenCreateExpense 3 android.
  4. Verify the warmup and all three measured replays pass and each reset returns to Inbox.
  5. Replay a flow with an intentionally wrong final assertion and verify it exits with code 1.
  6. Replay a five-second selector wait through the deadline wrapper with --timeout 1000 and verify it exits with code 124 near one second, cleans the isolated daemon, and releases the emulator.
  • Verify that no errors appear in the JS console

Offline tests

Not applicable because these changes only affect local automation tooling and do not change application networking or offline behavior.

QA Steps

Same as tests.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native

Not applicable. There are no application UI changes.

Android: mWeb Chrome

Not applicable. There are no application UI changes.

iOS: Native

Not applicable. There are no application UI changes.

iOS: mWeb Safari

Not applicable. There are no application UI changes.

MacOS: Chrome / Safari

Not applicable. There are no application UI changes.

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

Let's talk on monday about this pr @kacper-mikolajczak

@kacper-mikolajczak kacper-mikolajczak left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the initiative @BartekObudzinski! Left couple of comments in the first round of review to get more insight - thanks for the clarification in advance :)

-e KEY=VALUE
```

Tested Agent Device versions 0.20.1 through 0.20.3 accept `replay --timeout` but can leave the daemon request running until a selector timeout. The repository wrapper enforces the wall-clock deadline outside the daemon, exits with code 124, and cleans the dedicated headless daemon. Always use a separate `AGENT_DEVICE_STATE_DIR` for this workflow so timeout cleanup cannot stop an interactive session. Selector waits remain the per-step bounds and produce the useful divergence report.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like an upstream fix (if still exists on latest version).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the upstream part. We tested 0.20.1 through 0.20.3 and 0.20.5 is out, so I will retest and open an upstream issue if it still hangs.

The cleanup half stays either way. On timeout the wrapper kills the process group and runs daemon stop --clean against the isolated state dir, so the emulator gets released. If upstream fixes the hang, the wrapper shrinks to just that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Let me know how testing ended up going.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retested on 0.20.6 and it still hangs, so the wrapper stays.

Probe flow is a single step that waits 5000ms on a selector that can never match, run with --timeout 1000. Bare agent-device replay took 5505ms on iOS and 5262ms on Android, both exit 1. Through the wrapper it was 1690ms on iOS and 1333ms on Android, exit 124, and it printed Daemon stopped (graceful). Retained runner cleanup completed.

So the daemon request really does run to the selector timeout and the flag has no effect on wall clock. I will open the upstream issue and link it here. Updated the README line to say 0.20.1 through 0.20.6 with those numbers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, thanks! Let's see how quickly we can get upstream fix running.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like quite an expensive macro due to the wait times vs # of actions perfromed. How performant/useful it is based on your tests?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10000 is the selector wait timeout, not a sleep. wait "<selector>" 10000 polls and continues as soon as the selector resolves, so the real cost is the transition itself. Fixed duration waits are banned in the authoring rules.

The macro runs between measured runs in measure.sh, never inside the span. I have no recorded timing, so I will capture an Android run and post the actual reset duration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok! Let me know about testing, too :thx:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured it on the iOS simulator. return-to-inbox.ad runs in 1895ms, 1892ms, 1914ms across three samples, 4 steps in 1.7s of replay with the rest being node startup. switch-to-inbox.ad is 2101ms.

The alternative is worse than I expected. When a scenario has no @reset, measure.sh falls back to agent-device open --relaunch. I timed that path twice and it took 67298ms and 67011ms, and both times it never reached the scenario @pre. iOS restores the deep route on relaunch, so the snapshot afterwards still showed IOURequestStartPage plus a React Native warning overlay. So for these scenarios the relaunch fallback does not just cost a cold start, it fails outright.

That is the case for keeping the macro. Two seconds between measured runs against a fallback that does not get back to the starting screen at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reset numbers on Android too, now that I had an emulator up: 2639 / 2595 / 3540ms for return-to-inbox.ad. Slower than iOS but same order.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. I found those acceptable as a work-around, but in the long term how would you see us strengthening agent-device setup so it works seamlessly without static helpers like this? What is the root cause coming from your research (e.g. lack of enough locators, agent-device peformance, other setup issues)?

Thanks for digging into this @BartekObudzinski

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The root cause is two things, and only one is agent-device.

First, agent-device does not wait implicitly before an interaction. A press dispatches against whatever tree exists right now, so every transition needs a hand written selector wait after it. That is the upstream ask I would make: let press retry until the target resolves and is hittable, so the wait becomes the default rather than something the author remembers.

Second, a flow can only say where it must be, not how to get there. @pre is a selector. There is no way to name an app state and ask the tool to reach it, which is what forces a macro to exist at all. The fallback when a scenario has no @reset is open --relaunch, and I timed that at 67298ms and 67011ms on iOS, and neither run even landed on the @pre because iOS restores the deep route.

The third piece is on our side, not theirs. The Inbox tab needs a four way selector union because the label flips to "Inbox. Your review is required" depending on review state. Stable ids on the bottom tabs would collapse that union to one selector and shrink the macros to almost nothing.

# @pre id="floating-action-button" || role="button" label="Open actions menu"
# @post id="SearchPageNarrow" || id="Search"
# @post role="tab" label="Expenses" || role="button" label="Expenses" || label="Expenses" hittable=true
# @reset .claude/skills/agent-device/flows/macros/switch-to-inbox.ad

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I could use this as an example to understand concept behind reset tag. It is used to link macros between each other so they create kind of a pair that resets/mitigates itself, so the app lands on the same place?

Concept itself seems really nice, however I wonder how useful it would be. Let me know what's your experience/use-cases for that, thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One way, not a pair. A scenario points at a macro that restores that scenario's own @pre. The macro knows nothing about the scenario, so one macro serves several scenarios.

measure.sh is the consumer. It reads @reset and runs that macro between measured repeats. With no @reset it relaunches the app, which costs seconds and turns the next run into a cold start. That is the use case: N comparable samples instead of N samples with a relaunch mixed in.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, but seems like the reset macros added are quite simple like one lie i.e. Hit the back button. I wonder how much value it brings vs the alternative of inlining such or let the agent handle the reset based on pre conditions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are not one-liners. return-to-inbox is four statements: press Back, wait for the Inbox tab, press it, wait for BaseSidebarScreen. switch-to-inbox is three with an is exists guard first. Most of the bulk is the Inbox tab selector union, which has to cover the "Your review is required" label variant. Six scenarios share two macros, so inlining means copying that union six times and fixing it six times when a label changes.

On letting the agent reset from @pre: that does not work here. @pre is a selector saying where you must be, not a path saying how to get there. From an open expense screen there is nothing in id="BaseSidebarScreen" that tells you to press Back and then the Inbox tab.

Your question made me actually run this on iOS and it turned up a real bug, so thanks for pushing on it. Every one of these flows was Android-only. React Native maps role="tab" to no iOS accessibility trait, so the tabs come back as Other with hittable: false, and both halves of the role="tab" ... || label=... hittable=true union miss. switch-to-inbox.ad failed at step 1 on iOS and so did the five scenarios that inline the same union.

The dangerous part is what a naive fix does. I first tried appending a plain label="..." alternative, then checked what that actually presses: press label="Spend" tapped (201, 98), the page header, while the real tab sits at y=779. No error, replay continues against the wrong screen, which is exactly the false success this PR is supposed to prevent.

So the fix only adds a bare-label alternative where the label is provably unique, and the validator now requires the author to declare it with @unique-label instead of allowing bare labels generally. "Inbox. Your review is required" is unique in the tree. Home and Spend are not, so switch-home-to-inbox and switch-home-to-reports stay Android-only until the app puts a testID on each tab, which I noted in the README. Pushed with a test covering both the exempt and the still-rejected case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my last comment: I reverted the iOS fix out of this PR. It was scope creep on a PR you are already reviewing, and it added a new metadata field on top of everything else. The branch is back to what you reviewed.

The finding itself stands, I just moved it. Verified on Android after the revert, so nothing here regressed:

check result
switch-to-inbox.ad exit 0, 2346ms
open-create-expense.ad exit 0, 4864ms
return-to-inbox.ad exit 0, 2907ms
reset macro, 3 samples 2639 / 2595 / 3540ms
measure.sh ManualOpenCreateExpense 3 android exit 0, avg 389ms over 3 runs

That last one is the PR test step 4, which now has a real result: samples 400ms, 392ms, 375ms, and every reset returned to Inbox.

Two things I want to flag rather than quietly fix.

measure.sh defaults to PLATFORM="${3:-ios}", so a bare measure.sh <span> targets the platform where every scenario currently fails at the first tab press. Worth either changing the default to android or documenting it until the iOS work lands separately.

The first measure.sh run aborted with exit 1 in dismiss_react_native_overlays after five attempts. The overlay was genuine, an "Open debugger to view warnings" bar sitting directly on the nav tabs, so the guard is right to exist. But the same agent-device react-native dismiss-overlay returned "verified gone" on the first try when I ran it by hand a moment later, and then measure.sh completed. So five attempts with a one second sleep is a bit tight and a transient overlay kills the whole measurement. I have not touched it, happy to raise the attempts or add backoff if you think it is worth it.

Comment on lines +19 to +20
wait text "${EXPECTED_CHAT_TITLE}" 10000
wait "id=\"composer\"" 10000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those waits are added in order to prevent failure due to app being too slow to actually respond to the .ad script input, right?

How we could mitigate it on more robust level instead of manually inserting waits?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are selector waits. wait "id=\"composer\"" 10000 polls until the composer exists and continues then. 10000 is only where we give up and print the divergence report.

They are hand written because agent-device does not wait implicitly before a press. That is the upstream change worth asking for. For now validate-flows.mjs enforces the guard where it matters, for example a floating action button press must be followed by a selector wait.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it.

@kacper-mikolajczak

Copy link
Copy Markdown
Contributor

Unrelated to the replay --timeout hang discussed above - separate observation on the new .ad files.

agent-device's own CLI help documents --settle on press/fill/click/longpress/scroll/back as the preferred idiom over a manual follow-up wait <selector> <ms>, specifically for local (non-network) UI transitions. None of the new/touched flows use it - they all pair every press with a hand-written wait.

Candidates where the wait is purely local layout settling (tab switch / back nav, no server round-trip):

  • flows/macros/return-to-inbox.ad (both press/wait pairs)
  • flows/macros/switch-to-inbox.ad
  • flows/scenarios/switch-home-to-inbox.ad
  • flows/scenarios/switch-home-to-reports.ad

e.g. return-to-inbox.ad could become:

press "role=\"button\" label=\"Back\"" --settle
press "role=\"tab\" label=\"Inbox. Your review is required\" || ..." --settle

dropping the two manual waits.

Not suggesting this for the submit/send-message/create-expense flows - those waits are gating on server round-trips and Sentry spans, and the CLI docs are explicit that --settle's quiet-window doesn't cover network-backed/debounced results, so the existing explicit wait there is correct as-is.

Optional cleanup, not a blocker - flagging in case it trims the authoring-rules guidance too (README.md's "No fixed-duration wait calls" section could mention --settle as the preferred alternative for pure-nav mutations).

@kacper-mikolajczak

kacper-mikolajczak commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

@BartekObudzinski As I mentioned before, in theory I like this initiative, but adding extra tooling around like validation script to the repo that is not tied to any action (triggered 'by hand' only) does not increase stability. Mentioning the structure of flows inside readme/skill should be enough as an artifact.

For the replay with timeout - it is a patch for an upstream issue - I would wait until upstream is fixed instead of adding layers on top here.

tl;dr;

  • enhancing/re-fromatting flows is great
  • if reset and measure tags are adding value, then also good to have
  • if could be fixed upstream, then let's do it there
  • validation is not tied to anything -> drop/replace with docs

Let me know what you think, thanks! Sorry for being nit-picky :)

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

Agreed on the validation script. It was manual only and nothing ever ran it, so it was boilerplate pretending to be a gate. I deleted validate-flows.mjs and its test, and moved what it actually enforced into the authoring rules in the flows README: assertion order relative to mutations, no find ... "click", hittable=true on bare label alternatives, and one canonical owner per Sentry tag. I ran it one last time before deleting and every scenario passed, so nothing is being hidden by the removal.

Re-ran the Android checks after the removal to make sure nothing depended on it. measure.sh ManualOpenCreateExpense 3 android exits 0 with samples 531ms, 964ms and 358ms, and every reset landed back on the Inbox. A flow with a deliberately wrong final assertion still exits 1 and says why: wait timed out for selector: id="WrongPageOnPurpose". Current surface: Back, Manual, Scan, Per diem.

On the replay deadline I would keep it for now. Same emulator, same 0.20.6: bare replay --timeout 1000 ran 5317ms and exited 1, so the flag does nothing to wall clock, while the wrapper cut it at 1343ms with exit 124 and printed Daemon stopped (graceful). Retained runner cleanup completed. measure.sh is a real consumer, so dropping the wrapper means a measurement run can sit on a locked emulator until a selector timeout. Once upstream fixes it the wrapper goes away and measure.sh calls replay --timeout directly. I will link the upstream issue here.

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

On --settle: checked it on 0.20.6. It is there, but its own help says "best-effort; never fails the action". That is why I did not use it. wait "<selector>" 10000 is the failure signal, and this PR is mostly about flows failing instead of passing from the wrong screen. If I swap those two, return-to-inbox reports success whether or not the Inbox actually came up. The second wait there is the @post anchor, and in switch-home-to-inbox the final wait is where the measured span ends, so both are load bearing rather than layout settling.

I also cannot confirm the .ad parser honors per-line flags. replay export swallowed a made up flag without complaining, so that told me nothing. Happy to run it on a device if you want to know for sure.

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

Ran --settle on a device, and the answer turned out cleaner than the docs suggested: it is not supported in .ad scripts at all on 0.20.6. The replay parser folds the flag into the selector and the step fails with Invalid selector term "--settle", expected key=value. Same result with a plain id= selector, so it is not the || union. It is also specific to this flag rather than to flags in general, since press "id=\"floating-action-button\"" --count 1 replays fine.

The script-level equivalent that does work is wait stable, so I measured that too. Same Home to Inbox round trip, three reps each: wait stable averaged 2950ms against 2499ms for the selector wait, so it is slower. Then I pointed a flow at the wrong screen on purpose while it declared @post id="BaseSidebarScreen". wait stable exited 0 with the app sitting on Home, the selector wait exited 1. That false pass is the thing this PR removes, so the explicit waits stay.

Worth recording the part that favours your suggestion, though. The flag is genuinely useful off the replay path: it works on direct CLI commands, and batch honours it too when the step uses input.target.kind=selector with settle: true, returning settled, waitedMs and the diff under --json. One caveat if we ever lean on it: on a full screen swap it cost 3255ms and 79 lines against 682ms and 40 lines for press + snapshot -i, so it only pays off on small local mutations where the diff collapses.

None of that belongs in this PR, so let's treat --settle as its own follow-up against the interactive skill guidance and keep this one to the replay path.

@codecov

codecov Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
src/components/CategoryPicker/index.tsx 97.29% <ø> (ø)
src/libs/TransactionUtils/index.ts 91.44% <100.00%> (ø)
...egories/DynamicWorkspaceCategoriesSettingsPage.tsx 0.00% <0.00%> (ø)
...Navigation/AppNavigator/AuthScreensInitHandler.tsx 84.52% <57.14%> (-2.82%) ⬇️
... and 20 files with indirect coverage changes

@kacper-mikolajczak kacper-mikolajczak left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates since last review. Looking good! Left couple of comments

@@ -1,12 +1,13 @@
# Flows

This file is the reference for every `.ad` file in the repository: the metadata spec, selector rules, recording, and maintenance. Measurement flows live in [`measure-telemetry-span/flows/`](../../measure-telemetry-span/SKILL.md) and follow the same rules.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measurement flows live in measure-telemetry-span/flows/ and follow the same rules.

This could be omitted here - such information should be located in a skill, so whenever someone wants to use it, it's accessible from there but does not clutter outside.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, removed that sentence.

```bash
agent-device test .claude/skills/agent-device/flows/tests/<name>.ad -e KEY=VALUE ...
```
Tested Agent Device versions 0.20.1 through 0.20.6 accept `replay --timeout` but can leave the daemon request running until a selector timeout. For headless and measured runs, drive replay through [`measure-telemetry-span/scripts/replay-with-deadline.mjs`](../../measure-telemetry-span/SKILL.md), which enforces the wall-clock deadline outside the daemon and exits with code 124. Selector waits remain the per-step bounds and produce the useful divergence report.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am concerned about leaving such information as it could easily get outdated. E.g. current version of agent-device is 0.20.9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, please avoid mentioning the skill's implementation details outside of the skill itself please. Thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the whole paragraph. The version range was already stale and the deadline wrapper detail belongs in measure-telemetry-span, not here.

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

Hey @rlinoz @Julesssss opened a PR that reorganises the agent-device measurement setup: Sentry span flows moved into measure-telemetry-span, replays now fail loudly on wrong screen or missing span instead of silently spending all runs, and each span has exactly one owning flow so the runner never picks by filesystem order. wdyt?

@BartekObudzinski
BartekObudzinski force-pushed the fix/agent-device-headless-replay-trustworthy branch from fe63dbd to 1638f72 Compare August 27, 2026 11:50
@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

The PR is updated @rlinoz @Julesssss @kacper-mikolajczak

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

Kindly bumping @Julesssss @rlinoz @kacper-mikolajczak

@kacper-mikolajczak kacper-mikolajczak left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM in general but please make sure that we are avoiding unnecessary coupling between task-specific skill and general instructions. Thanks!

@@ -1,8 +1,7 @@
context platform=android
# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad.
# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the flow that measures the ManualSendMessageVisible Sentry span, see measure-telemetry-span/flows/send-message.ad.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like unnecessary mention of out-of-scope tool. Let's treat flows as abstract entities.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. The @desc now stops at "assumes the composer is visible" and says nothing about spans or the other skill. Same edit on the web and unscoped copies.

@@ -1,8 +1,7 @@
context platform=web
# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad.
# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the flow that measures the ManualSendMessageVisible Sentry span, see measure-telemetry-span/flows/send-message.ad.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as for send-message

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed here too.

@@ -1,7 +1,6 @@
# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad.
# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the flow that measures the ManualSendMessageVisible Sentry span, see measure-telemetry-span/flows/send-message.ad.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

- **Confirm every selector on the platform it is written for.** `snapshot -i` prints display tags, which are not selector values - a node printed as `[text-field]` may only match `role="textbox"`. Check the exit code of `agent-device is visible "<selector>"` before committing a selector, and never carry one across platforms unchecked.
- **Every flow declares `@desc` and `@pre`.** Add `@post` for outcome-bearing flows; utility flows (for example `go-back`) may omit it. Add `@tag` when applicable.
- **Choose directory intentionally.** Put reusable setup/navigation steps in `flows/macros/`; put outcome verification scenarios in `flows/tests/`.
- **Every flow declares `@desc` and `@pre`.** Measurement flows also declare at least one `@post`. Utility macros (for example `go-back`) may omit `@post`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Referencing out of scoep detail about measuremnet flows

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You flagged line 84 but the coupling was in five places, so I took all of them out.

Dropped the @span and @reset rows from the metadata table, the "One owner per span" rule, and the "Choose the skill intentionally" rule. Line 84 now says "Outcome-bearing flows" instead of "Measurement flows". The recording step no longer offers measure-telemetry-span/flows/ as a destination or mentions @span.

Nothing is lost by moving them. measure-telemetry-span/SKILL.md already documents @span, @reset, and the one owner per span rule in its Contract section, so those headers stay described in the skill that consumes them. The dependency now runs one way, the measurement skill points up at this README for the shared spec and this README knows nothing about it.

@rlinoz

rlinoz commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Can we mark this one as ready for review?

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

@rlinoz Sure, but do we need ticket?

@BartekObudzinski
BartekObudzinski marked this pull request as ready for review September 4, 2026 06:47
@BartekObudzinski
BartekObudzinski requested a review from a team as a code owner September 4, 2026 06:47
@melvin-bot
melvin-bot Bot requested review from Valforte and removed request for a team September 4, 2026 06:48
@melvin-bot

melvin-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

@Valforte Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@rlinoz

rlinoz commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Ah sorry, I thought we had one, here you go #100391

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Android test steps verification results: ⚠️ partial

Ran all six documented steps on a standalone Android NewDot build (com.expensify.chat.dev, developmentDebug, emulator-5554, agent-device 0.20.10). The tooling this PR changes is sound — the unit tests, shellcheck, the wrong-screen failure path and the deadline wrapper all behave exactly as documented, and once I disambiguated the flow locators measure.sh produced clean 3-sample tables on both the relaunch branch and the @reset branch. What I could not do is run step 3 as written: the shipped flows fail on this build because the bottom nav is duplicated in the accessibility tree, so find "Inbox" matches 4 nodes.

Emulator left on Home and interactive after the deadline wrapper stopped the isolated daemon and released the device

Step Status
1. node --test .claude/skills/measure-telemetry-span/scripts/replay-with-deadline.test.mjs passes all 3 testsExit code 0.
✔ returns the replay exit code (36.4ms)
✔ terminates replay at the deadline and starts session cleanup (269.5ms)
✔ handles repeated SIGTERM and cleans the isolated daemon (1107.9ms)
tests 3 / pass 3 / fail 0 / cancelled 0 / skipped 0 / todo 0
✅
2. bash -n measure.sh and npx shellcheck -e SC1091 measure.shbash -n → exit 0, no output.
npx shellcheck -e SC1091 → exit 0, zero findings, no stdout or stderr at all.
✅
3. measure.sh ManualOpenCreateExpense 3 android as shippedAPP_ID left at its default — adb shell pm list packages returns only package:com.expensify.chat.dev.

Blocker A — device ownership, exit 1:
Error (DEVICE_IN_USE): android device emulator-5554 is owned by session "melvin-android-main"
The isolated AGENT_DEVICE_STATE_DIR daemon still shares the host-local claim registry ~/.agent-device/device-claims, so it can never acquire the emulator while another session is live. Worked around with AGENT_DEVICE_CLAIMS_DIR=/tmp/ad-claims-headless (env only; the CI session was never closed).

Blocker B — exit 1:
Warning: Android UI not ready after 60s (no Home/Inbox tabs); proceeding anyway.
Flow @pre not satisfied: text="Inbox"
--relaunch re-raised the Android notification-permission dialog, which dismiss_react_native_overlays does not know about (it only handles LogBox).

Blocker C — deterministic, exit 1, no table:
Error (REPLAY_DIVERGENCE): Replay failed at step 1 (find "Inbox" "click"): find matched 4 elements for any "Inbox".
Divergence at step 1 (.../flows/open-create-expense.ad:6)
Confirmed independently: agent-device find Inbox → AMBIGUOUS_MATCH on @e23/@e24/@e39/@e40, and every snapshot warns possible repeated nav subtree detected (NavigationTabBar rendered twice with identical rects).
Android NewDot Home/Inbox immediately before the first measure.sh run
❌
3 (retry, disambiguated flow). Does measure.sh itself work end to end?Yes. measure.sh has no FLOWS_DIR override, so I copied the whole skill to /tmp/mts — diff confirms measure.sh and scripts/replay-with-deadline.mjs are byte-identical to the tracked ones, and the flow's five # @ header lines are byte-identical too. The repo tree was never touched.

Three body changes were needed:
1. find "Inbox" "click" → press 324 2244. No unique selector exists on this build: both copies sit at the identical rect, and label="Inbox" (4 matches), label="Inbox" hittable=true (2), role="text" label="Inbox" hittable=true / selected=true / focused=true (0) all fail.
2. press "role=\"button\" label=\"Open actions menu\"" → press "id=\"floating-action-button\"". The bare label also matches the Concierge composer "+" button.
3. Added wait "role=\"button\" label=\"Create expense\" || label=\"Create expense\"" 10000 before the final press. Without it the run fails twice at step 3 while the divergence report still shows the pre-sheet Home surface — the FAB press returns before the actions sheet mounts and replay does not re-resolve. This looks like a real robustness gap in the shipped flow, separate from the duplicated nav.

/tmp/mts/measure.sh ManualOpenCreateExpense 3 android → exit 0:
Runs 3 | Avg 1703ms | Min 1591ms | Max 1866ms
Samples: 1653ms, 1591ms, 1866ms
Create expense screen with Manual/Scan tabs, the @post anchor reached by each measured replay
✅
4. Warmup and all three measured replays pass, each reset returns to Inbox (flow as shipped)Never reached, and partly inapplicable as worded: open-create-expense.ad declares no @reset, so measure.sh takes the relaunch branch at measure.sh:274 and never prints Resetting with: for this span. The run aborted before the warmup replay finished — first at Flow @pre not satisfied: text="Inbox", then at the find "Inbox" divergence — so no warmup line and no Run i/3 line was ever emitted.
❌
4 (retry). Warmup plus 3 runs pass, and both reset branches behaveRelaunch branch (ManualOpenCreateExpense, no @reset) — exit 0, warmup and all three runs passed with @pre/@post enforced each iteration and a fresh span line each time:
Replayed 4 steps in 3.8s (warmup) → Opened: com.expensify.chat.dev (reset) → Run 1/3 3.5s → Run 2/3 2.9s → Run 3/3 2.3s. No "not satisfied", no "emitted no ... Ending span".

@reset branch, part 1 — tracked switch-home-to-inbox.ad copied verbatim, measure.sh ManualNavigateToInboxTab 3 android → exit 1. It does reach the branch this step asks about, then the shipped macro itself fails:
Using reset flow: .../macros/switch-to-home.ad
Resetting with: .../macros/switch-to-home.ad
Error (REPLAY_DIVERGENCE): Replay failed at step 2 (press "label=\"Home\""): Selector matched 4 distinct actionable elements
Same duplicated-nav root cause.

@reset branch, part 2 — with only the two tab presses disambiguated by coordinates → exit 0:
Runs 3 | Avg 326ms | Min 248ms | Max 377ms
Samples: 377ms, 248ms, 353ms
stderr shows the full loop: Resetting with: /tmp/mts/macros/switch-to-home.ad five times (before warmup, after warmup, after each run), each reset Replayed 3 steps in 0.5s, each measured replay Replayed 4 steps, with Run 1/3, Run 2/3, Run 3/3 and no @pre/@post failures.
✅
5. Flow with an intentionally wrong final assertion exits 1Copies written to /tmp/adflows/ only; no tracked file modified.

(A) wrongpost-minimal.ad (@post id="WrongPageOnPurpose" plus a matching final body assertion) → exit code 1:
Error (REPLAY_DIVERGENCE): Replay failed at step 2 (is "exists" "id=\"WrongPageOnPurpose\""): Selector did not match: id="WrongPageOnPurpose"
Divergence at step 2 (/tmp/adflows/wrongpost-minimal.ad:5)
Screen: 20 actionable ref(s) captured. @e29 [group] "HomePage" / @e33 [group] "Home" / @e34 [button] "Search" / @e42 [text] "Good morning, Melvin." / ...
The failing selector is named verbatim and the current surface is dumped — exactly the behaviour the PR is adding.

(B) A faithful copy of the tracked flow with the same swap also exits 1, but dies earlier at the find "Inbox" ambiguity. Worth noting: @post metadata alone is not executed by replay (flows README — "The flow body enforces them"), so only variant (A) exercises the intended path.
Screen the app was on when the step-5 wrong-assertion replay failed, matching the HomePage surface in the divergence report
✅
6. Five-second selector wait through the deadline wrapper with --timeout 1000 exits 124 near one second, cleans the daemon, releases the emulatorTemp flow /tmp/adflows/slow-wait.ad: wait "id=\"NeverMatchesOnPurpose\"" 5000.
node .claude/skills/measure-telemetry-span/scripts/replay-with-deadline.mjs /tmp/adflows/slow-wait.ad --timeout 1000 --platform android → exit code 124, measured wall clock 1305 ms (timed with date +%s%3N around the node call — ~1000 ms deadline plus node startup), so the 5000 ms wait was genuinely cut short.
Output:
Replay exceeded 1000ms. Terminating the client and cleaning the isolated daemon.
Daemon stopped (graceful).
Retained runner cleanup completed.
Release proven immediately after: agent-device snapshot -i → exit 0, 31 visible nodes, Home rendered; only the CI daemon process remained; the isolated claims dir was empty; agent-device device status still reported the untouched CI session.
Also confirmed the wrapper refuses to run without AGENT_DEVICE_STATE_DIR (AGENT_DEVICE_STATE_DIR is required so timeout cleanup cannot stop an unrelated daemon., exit 2) — that guard is what kept the live session safe.
Emulator left on Home and interactive after the deadline wrapper stopped the isolated daemon and released the device
✅

Three things worth fixing before merge

  1. Ambiguous selectors in the shipped flows. On this build the bottom nav appears twice in the a11y tree, so open-create-expense.ad:6 (find "Inbox", plus the bare label="Open actions menu"), switch-to-home.ad:5 (press "label=\"Home\"") and switch-home-to-inbox.ad (press "label=\"Inbox\"") are all unrunnable as written. Since the whole point of the PR is that flows fail loudly instead of passing from the wrong screen, they do fail loudly — but nobody can run step 3 out of the box.
  2. open-create-expense.ad needs a wait before the FAB sheet. The press returns before the actions sheet mounts, and replay does not re-resolve, so step 3 of the flow misses Create expense on a surface that is otherwise correct.
  3. The two reset call sites are not equivalent. The pre-warmup reset at measure.sh:272 calls run_replay directly, so under set -e it aborts with no retry, while reset_if_needed retries once. Worth making them match.

Separately, and outside this PR's diff: measure.sh cannot acquire the device when any other agent-device session holds the host-local claim, because the isolated AGENT_DEVICE_STATE_DIR does not isolate ~/.agent-device/device-claims. That undercuts the SKILL's "isolated daemon" promise on any host with a live session, and it is what made step 3 need an AGENT_DEVICE_CLAIMS_DIR override here.

The emulator was left on Home and the App working tree is clean apart from the pipeline's own CI-only LogBox edit to src/App.tsx. All temp flows lived under /tmp.

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

All three fixed, plus the claims registry one, which was a single line since measure.sh can just default AGENT_DEVICE_CLAIMS_DIR inside its own state dir. Root cause was a merge from main on 20 August that resolved the flow files in favour of upstream and threw away this branch's own work, so the nine flows had lost their id anchored assertions, their waits and return-to-inbox.ad, all now restored with find ... "click" replaced by real ids. The duplicated nav is by design via TabBarBottomContent, and press has no way to pick among matches, so the three flows that must press a tab stay blocked on per tab testIDs, and I have not run any of this on a device since I am on 0.20.6 where your stricter press error does not exist.

@rlinoz

rlinoz commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

@BartekObudzinski does it really need all those \ ?

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

@rlinoz Mostly yes. Quoted arguments go through JSON.parse, so any selector containing a space needs the wrapper, and then its inner quotes need escaping. Without the backslashes those do not error, they parse into a different selector. I left them uniform because healing a selector means adding a || alternative, which on a bare line means adding the wrapper and escaping by hand

@rlinoz

rlinoz commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
Android: mWeb Chrome
iOS: HybridApp
iOS: mWeb Safari
MacOS: Chrome / Safari

@rlinoz
rlinoz merged commit 994d897 into Expensify:main Sep 7, 2026
20 of 22 checks passed
@OSBotify

OSBotify commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify OSBotify mentioned this pull request Sep 8, 2026
69 tasks done
@izarutskaya

izarutskaya commented Sep 8, 2026 •

Copy link
Copy Markdown

@BartekObudzinski Looks steps are for internal testing in Tests section, could you please share steps for QA team? Thanks

@rlinoz

rlinoz commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

This is a NO QA @izarutskaya sorry

@OSBotify

OSBotify commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/rlinoz in version: 9.4.74-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@OSBotify

OSBotify commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/mountiny in version: 9.4.74-2 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants